/*-
 * ========================LICENSE_START=================================
 * restheart-core
 * %%
 * Copyright (C) 2014 - 2025 SoftInstigate
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 * =========================LICENSE_END==================================
 */
package org.restheart.handlers;

import static org.fusesource.jansi.Ansi.ansi;
import static org.fusesource.jansi.Ansi.Color.GREEN;
import static org.fusesource.jansi.Ansi.Color.RED;
import static org.restheart.plugins.security.TokenManager.AUTH_TOKEN_HEADER;

import java.util.Deque;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;

import org.restheart.Bootstrapper;
import org.restheart.configuration.Configuration;
import org.restheart.exchange.ByteArrayProxyResponse;
import org.restheart.exchange.JsonProxyRequest;
import org.restheart.logging.RequestPhaseContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import io.undertow.security.api.SecurityContext;
import io.undertow.server.ExchangeCompletionListener;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.Cookie;
import io.undertow.util.HeaderValues;
import io.undertow.util.Headers;
import io.undertow.util.LocaleUtils;

/**
 *
 * @author Andrea Di Cesare {@literal <andrea@softinstigate.com>}
 */
public class RequestLogger extends PipelinedHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger(RequestLogger.class);

    // Last logged time for excluded requests per pattern (in milliseconds)
    private static final ConcurrentHashMap<String, Long> LAST_LOGGED_TIME = new ConcurrentHashMap<>();

    // Cache compiled regex patterns to avoid recompilation
    private static final ConcurrentHashMap<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();

    // Constants for time calculations to avoid repeated multiplication
    private static final long MINUTES_TO_MS = 60 * 1000;

    // Cache color formatting strings to avoid repeated ansi() object creation
    private static final String RED_BOLD_FORMAT = ansi().fg(RED).bold().toString();
    private static final String GREEN_BOLD_FORMAT = ansi().fg(GREEN).bold().toString();
    private static final String RESET_FORMAT = ansi().reset().toString();

    // Cache immutable configuration values to avoid repeated access
    private final int requestsLogMode;
    private final List<String> requestsLogExcludePatterns;
    private final long requestsLogExcludeInterval;
    private final long requestsLogExcludeIntervalMs; // Pre-calculated interval in milliseconds

    // Optimization flag: true if there are exclusion patterns to check
    private final boolean hasExclusionPatterns;

    /**
     * Creates a new instance of RequestLoggerHandler
     *
     */
    public RequestLogger() {
        this(null);
    }

    /**
     * Creates a new instance of RequestLoggerHandler
     *
     * @param next
     */
    public RequestLogger(final PipelinedHandler next) {
        super(next);
        // Cache immutable configuration values at startup
        final Configuration config = Bootstrapper.getConfiguration();
        if (config != null) {
            this.requestsLogMode = config.logging().requestsLogMode();
            this.requestsLogExcludePatterns = config.logging().requestsLogExcludePatterns();
            this.requestsLogExcludeInterval = config.logging().requestsLogExcludeInterval();
            this.requestsLogExcludeIntervalMs = requestsLogExcludeInterval * MINUTES_TO_MS; // Pre-calculate
            this.hasExclusionPatterns = !requestsLogExcludePatterns.isEmpty();
        } else {
            // Fallback for testing scenarios where configuration is not available
            this.requestsLogMode = 0;
            this.requestsLogExcludePatterns = java.util.Collections.emptyList();
            this.requestsLogExcludeInterval = 0;
            this.requestsLogExcludeIntervalMs = 0;
            this.hasExclusionPatterns = false;
        }
    }

    /**
     *
     * @param exchange
     * @throws Exception
     */
    @Override
    public void handleRequest(final HttpServerExchange exchange) throws Exception {
        // Use ScopedValue to set the exchange for the entire request processing pipeline
        RequestPhaseContext.runWithExchange(exchange, () -> {
            if (requestsLogMode > 0 && LOGGER.isInfoEnabled()) {
                // Optimization: only check for exclusion patterns if any are configured
                if (hasExclusionPatterns) {
                    // Check if the request path should be excluded from logging
                    final String requestPath = exchange.getRequestPath();
                    final String matchedPattern = findMatchingPattern(requestPath);

                    if (matchedPattern != null) {
                        // Request matches an exclusion pattern
                        handleExcludedRequest(exchange, requestPath, matchedPattern);
                    } else {
                        // Normal request - log it
                        dumpExchange(exchange, requestsLogMode);
                    }
                } else {
                    // No exclusion patterns configured - always log
                    dumpExchange(exchange, requestsLogMode);
                }
            }

            next(exchange);
        });
    }

    /**
     * Finds the first pattern that matches the request path
     * 
     * @param requestPath
     *            the request path to check
     * @return the matching pattern or null if no match
     */
    private String findMatchingPattern(final String requestPath) {
        // Early exit optimization for empty patterns (should not happen due to hasExclusionPatterns check)
        if (requestsLogExcludePatterns.isEmpty()) {
            return null;
        }

        // Optimize for single pattern case (very common scenario)
        if (requestsLogExcludePatterns.size() == 1) {
            final String pattern = requestsLogExcludePatterns.get(0);
            return matchesPattern(requestPath, pattern)
                ? pattern
                : null;
        }

        // Multiple patterns - use stream for flexibility
        return requestsLogExcludePatterns.stream()
                .filter(pattern -> matchesPattern(requestPath, pattern))
                .findFirst()
                .orElse(null);
    }

    /**
     * Handles an excluded request with time-based logging
     * 
     * @param exchange
     *            the HTTP exchange
     * @param requestPath
     *            the request path
     * @param matchedPattern
     *            the pattern that matched
     */
    private void handleExcludedRequest(final HttpServerExchange exchange, final String requestPath,
            final String matchedPattern) {
        final long now = System.currentTimeMillis();

        // Use putIfAbsent for thread-safe first-time detection
        final Long previousValue = LAST_LOGGED_TIME.putIfAbsent(matchedPattern, now);

        if (previousValue == null) {
            // This thread was first to add the pattern
            if (requestsLogExcludeInterval <= 0) {
                LOGGER.info("First excluded request for pattern '{}' (logging disabled for subsequent requests):",
                        matchedPattern);
            } else {
                LOGGER.info("First excluded request for pattern '{}' (will log again every {} minutes):",
                        matchedPattern, requestsLogExcludeInterval);
            }
            dumpExchange(exchange, requestsLogMode);
        } else if (requestsLogExcludeInterval > 0 && (now - previousValue) >= requestsLogExcludeIntervalMs) {
            // Time interval has elapsed - use replace for atomic update
            if (LAST_LOGGED_TIME.replace(matchedPattern, previousValue, now)) {
                final long minutesElapsed = (now - previousValue) / MINUTES_TO_MS;
                LOGGER.info("Excluded request for pattern '{}' (last logged {} minutes ago):",
                        matchedPattern, minutesElapsed);
                dumpExchange(exchange, requestsLogMode);
            }
            // If replace failed, another thread already logged this interval
        }
        // Otherwise, request is silently excluded
    }

    /**
     * Checks if a request path matches an exclusion pattern.
     * Supports exact matches and simple wildcard patterns with '*'.
     * 
     * @param requestPath
     *            the request path to check
     * @param pattern
     *            the exclusion pattern
     * @return true if the path matches the pattern
     */
    private boolean matchesPattern(final String requestPath, final String pattern) {
        if (pattern.equals(requestPath)) {
            // Exact match
            return true;
        }

        // Check for wildcard patterns more efficiently using indexOf instead of contains
        final int wildcardIndex = pattern.indexOf('*');
        if (wildcardIndex >= 0) {
            // Use cached compiled patterns to avoid recompilation
            final Pattern compiledPattern = PATTERN_CACHE.computeIfAbsent(pattern, p -> {
                final String regexPattern = p
                        .replace(".", "\\.")
                        .replace("*", ".*");
                return Pattern.compile(regexPattern);
            });
            return compiledPattern.matcher(requestPath).matches();
        }
        return false;
    }

    /**
     * dumpExchange
     *
     * Log a complete dump of the HttpServerExchange (both Request and Response)
     *
     * @param exchange
     *            the HttpServerExchange
     * @param logLevel
     *            it can be 0, 1 or 2
     */
    protected void dumpExchange(final HttpServerExchange exchange, final Integer logLevel) {
        if (logLevel < 1) {
            return;
        }

        final var request = JsonProxyRequest.of(exchange);

        // Pre-allocate StringBuilder with appropriate capacity based on log level
        final StringBuilder sb = new StringBuilder(logLevel == 1
            ? 256
            : 2048);
        final long start = request != null && request.getStartTime() != null
            ? request.getStartTime()
            : System.currentTimeMillis();

        if (logLevel == 1) {
            sb.append(exchange.getRequestMethod()).append(" ").append(exchange.getRequestURL());

            if (exchange.getQueryString() != null && !exchange.getQueryString().isEmpty()) {
                sb.append("?").append(exchange.getQueryString());
            }

            sb.append(" from ").append(exchange.getSourceAddress());
        } else if (logLevel >= 2) {
            sb.append("\n----------------------------REQUEST---------------------------\n");

            sb.append("               URI=").append(exchange.getRequestURI()).append("\n");

            final var pb = request == null
                ? null
                : request.getPipelineInfo();

            if (pb != null) {
                sb.append("          servedBy=")
                        .append(pb.getType().name().toLowerCase())
                        .append(" ");

                if (pb.getName() != null) {
                    sb
                            .append("'")
                            .append(pb.getName())
                            .append("' ");
                }

                sb
                        .append("bound to '")
                        .append(pb.getUri())
                        .append("'\n");
            }

            sb.append(" characterEncoding=").append(exchange.getRequestHeaders().get(Headers.CONTENT_ENCODING))
                    .append("\n");
            sb.append("     contentLength=").append(exchange.getRequestContentLength()).append("\n");
            sb.append("       contentType=").append(exchange.getRequestHeaders().get(Headers.CONTENT_TYPE))
                    .append("\n");

            @SuppressWarnings("removal")
            final Map<String, Cookie> cookies = exchange.getRequestCookies();
            if (cookies != null) {
                cookies.entrySet().stream().map((entry) -> entry.getValue()).forEach((cookie) -> {
                    sb.append("            cookie=").append(cookie.getName()).append("=").append(cookie.getValue())
                            .append("\n");
                });
            }
            for (final HeaderValues header : exchange.getRequestHeaders()) {
                header.stream().forEach((value) -> {
                    if (header.getHeaderName() != null
                            && "Authorization".equalsIgnoreCase(header
                                    .getHeaderName().toString())) {
                        value = "**********";
                    }

                    sb.append("            header=").append(header.getHeaderName()).append("=").append(value)
                            .append("\n");
                });
            }
            sb.append("            locale=")
                    .append(LocaleUtils.getLocalesFromHeader(exchange.getRequestHeaders().get(Headers.ACCEPT_LANGUAGE)))
                    .append("\n");
            sb.append("            method=").append(exchange.getRequestMethod()).append("\n");
            final Map<String, Deque<String>> pnames = exchange.getQueryParameters();
            pnames.entrySet().stream().map((entry) -> {
                final String pname = entry.getKey();
                final Iterator<String> pvalues = entry.getValue().iterator();
                sb.append("         parameter=");
                sb.append(pname);
                sb.append('=');
                while (pvalues.hasNext()) {
                    sb.append(pvalues.next());
                    if (pvalues.hasNext()) {
                        sb.append(", ");
                    }
                }
                return entry;
            }).forEach((_item) -> {
                sb.append("\n");
            });

            sb.append("          protocol=").append(exchange.getProtocol()).append("\n");
            sb.append("       queryString=").append(exchange.getQueryString()).append("\n");
            sb.append("        remoteAddr=").append(exchange.getSourceAddress()).append("\n");
            sb.append("        remoteHost=").append(exchange.getSourceAddress().getHostName()).append("\n");
            sb.append("            scheme=").append(exchange.getRequestScheme()).append("\n");
            sb.append("              host=").append(exchange.getRequestHeaders().getFirst(Headers.HOST)).append("\n");
            sb.append("        serverPort=").append(exchange.getDestinationAddress().getPort()).append("\n");
        }

        addExchangeCompleteListener(exchange, logLevel, sb, start);
    }

    private void addExchangeCompleteListener(final HttpServerExchange exchange, final Integer logLevel,
            final StringBuilder sb,
            final long start) {
        exchange.addExchangeCompleteListener(
                (final HttpServerExchange exchange1, final ExchangeCompletionListener.NextListener nextListener) -> {
                    if (logLevel < 1) {
                        return;
                    }

                    // restore MDC context
                    // MDC context is put in the thread context
                    // A thread switch in the request handling pipeline loses the MDC context.
                    // TracingInstrumentationHandler adds it to the
                    // exchange as an Attachment
                    final var mdcCtx = ByteArrayProxyResponse.of(exchange).getMDCContext();
                    if (mdcCtx != null) {
                        MDC.setContextMap(mdcCtx);
                    }

                    // note sc is always null if this handler is chained before
                    // SecurityHandlerDispacher
                    final SecurityContext sc = exchange1.getSecurityContext();

                    if (logLevel == 1) {
                        sb.append(" =>").append(" status=");

                        if (exchange.getStatusCode() >= 300 && exchange.getStatusCode() != 304) {
                            sb.append(RED_BOLD_FORMAT).append(exchange.getStatusCode()).append(RESET_FORMAT);
                        } else {
                            sb.append(GREEN_BOLD_FORMAT).append(exchange.getStatusCode()).append(RESET_FORMAT);
                        }

                        sb.append(" elapsed=").append(System.currentTimeMillis() - start).append("ms")
                                .append(" contentLength=").append(exchange1.getResponseContentLength());

                        if (sc != null && sc.getAuthenticatedAccount() != null) {
                            sb.append(" ").append(sc.getAuthenticatedAccount().toString());
                        }
                    } else if (logLevel >= 2) {
                        sb.append("--------------------------RESPONSE--------------------------\n");
                        if (sc != null) {
                            if (sc.isAuthenticated()) {
                                sb.append("          authType=").append(sc.getMechanismName()).append("\n");
                                sb.append("          account=").append(sc.getAuthenticatedAccount().toString())
                                        .append("\n");
                            } else {
                                sb.append("          authType=none").append("\n");
                            }
                        }

                        sb.append("     contentLength=").append(exchange1.getResponseContentLength()).append("\n");
                        sb.append("       contentType=")
                                .append(exchange1.getResponseHeaders().getFirst(Headers.CONTENT_TYPE)).append("\n");

                        @SuppressWarnings("removal")
                        final Map<String, Cookie> cookies1 = exchange1.getResponseCookies();
                        if (cookies1 != null) {
                            cookies1.values().stream().forEach((cookie) -> {
                                sb.append("            cookie=").append(cookie.getName()).append("=")
                                        .append(cookie.getValue()).append("; domain=").append(cookie.getDomain())
                                        .append("; path=").append(cookie.getPath()).append("\n");
                            });
                        }
                        for (final HeaderValues header : exchange1.getResponseHeaders()) {
                            header.stream().forEach((value) -> {
                                if (header.getHeaderName() != null
                                        && AUTH_TOKEN_HEADER.toString().equalsIgnoreCase(header
                                                .getHeaderName().toString())) {
                                    value = "**********";
                                }

                                sb.append("            header=").append(header.getHeaderName()).append("=")
                                        .append(value).append("\n");
                            });
                        }
                        sb.append("            status=");

                        if (exchange.getStatusCode() >= 300) {
                            sb.append(RED_BOLD_FORMAT).append(exchange1.getStatusCode()).append(RESET_FORMAT);
                        } else {
                            sb.append(GREEN_BOLD_FORMAT).append(exchange1.getStatusCode()).append(RESET_FORMAT);
                        }

                        sb.append("\n");

                        sb.append("           elapsed=").append(System.currentTimeMillis() - start).append("ms\n");
                        sb.append("==============================================================");
                    }

                    nextListener.proceed();
                    RequestPhaseContext.setPhase(RequestPhaseContext.Phase.STANDALONE);
                    LOGGER.info(sb.toString());
                });
    }
}
